Skip to content

fix(consensus): hold delegated EOAs to their key in delegateStake - #165

Merged
Huwonk merged 3 commits into
masterfrom
fix/eip7702-delegation-signature
Aug 12, 2026
Merged

fix(consensus): hold delegated EOAs to their key in delegateStake#165
Huwonk merged 3 commits into
masterfrom
fix/eip7702-delegation-signature

Conversation

@Huwonk

@Huwonk Huwonk commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes #163, closes #164.

#163 - delegateStake silently switches from ecrecover to ERC-1271

Confirmed, both halves, with tests that fail on master.

SignatureCheckerLib.isValidSignatureNowCalldata branches on extcodesize before anything else. A 7702-delegated EOA carries 23 bytes of 0xef0100 || implementation, so the branch is not taken and ecrecover is never reached. The check becomes an ERC-1271 staticcall against whatever the EOA currently delegates to.

  • Liveness. test_delegateStake_delegatedEOA_acceptsKeySignature reverts NotValidator on master even though the validator signed the digest with its own key. Any 7702 wallet without a matching ERC-1271 handler is unonboardable, with nothing in the revert to say why.
  • Authorization. testRevert_delegateStake_permissiveDelegateCannotAuthorize succeeds on master with the four-byte signature 0xdeadbeef. The digest is public via tn_delegationDigest, so a permissive handler is enough to bind an arbitrary delegator, and _getRecipient sends every later claimStakeRewards and unstake there.

Fix

Route on what kind of account the validator actually is rather than on code size:

if (_isDelegationDesignator(validatorAddress)) {
    return ECDSA.tryRecoverCalldata(digest, signature) == validatorAddress;
}
return SignatureCheckerLib.isValidSignatureNowCalldata(validatorAddress, digest, signature);

The designator is matched by its 0xef0100 prefix, not merely its length. EIP-3541 forbids deployed code from starting with 0xEF, so the prefix is unambiguous, and a real 23-byte contract still routes to ERC-1271 (testRevert_delegateStake_twentyThreeByteContractIsNotADesignator).

Delegated EOAs are now held to their secp256k1 key exactly as undelegated ones are; only genuine contract accounts reach ERC-1271, and test_delegateStake_contractValidator_usesERC1271 pins that we did not break them. An operator who has moved to a smart account and no longer holds the key still has two routes in: stake, which authorizes off msg.sender, and the governance path, which skips the signature check entirely.

#164 - 7702 dissolves EOA screening

The premise holds - screening at mint time is not durable, a holder can attach a reverting delegation afterwards - but the described exploit chain does not reproduce. burn and the slash-to-zero branch of applySlashes both succeed today against a validator whose account rejects every incoming call.

_consensusBurn zeroes balances[validatorAddress] and consolidates the stake on Issuance before reaching _unstake, so _unstake returns at its zero-balance guard and never calls distributeStakeReward. No value is pushed to the validator, and there is no burn payout to convert to a pull-based credit.

What was true is that the property was incidental rather than structural, and actively obscured: _consensusBurn called a function named _unstake, passing a recipient it computed and never used. So rather than a no-op fix, this PR makes the property explicit and pins it:

  • _burnConsensusNFT is split out of _unstake (token burn, supply guard, delegation clear - no value movement) and the confiscating path calls it directly. Behavior is unchanged; the misleading payout call is gone.
  • Four regression tests cover both ejection paths against hostile account code: a reverting 7702 delegation on the validator, on the delegator (the actual payout recipient when delegated), and the queued version-change escrow, which is the one balance an ejection owes back and which already credits claimRefund rather than pushing. The slash test also concludes the following epoch, since that path runs inside a system call where a revert stalls the closing block rather than merely inconveniencing governance.

These four pass on master too. That is the point: they document a property that currently holds by accident and would otherwise be easy to refactor away.

nonReentrant is deliberately not added to burn. It is onlyOwner and, after this change, provably makes no call to an untrusted address.

Testing

Also included

artifacts/ConsensusRegistry.json regenerated, since the source changed and the committed artifact is consumed downstream. Docs updated: the delegateStake natspec now states which authority is competent to sign, and design.md gains bullets for delegation authorization under account abstraction and for the push-free ejection property.

`delegateStake` verified the validator's EIP-712 approval with
`SignatureCheckerLib.isValidSignatureNowCalldata`, which branches on
`extcodesize` first. An EIP-7702 delegated EOA carries 23 bytes of
`0xef0100 || implementation`, so the branch is not taken, `ecrecover` is
never reached, and the check becomes an ERC-1271 staticcall against
whatever wallet program the account currently points at.

That substitutes the wrong authority in both directions. A validator on a
7702 wallet without an ERC-1271 handler cannot be onboarded at all: its
valid key signature is ignored and the call reverts `NotValidator` with
nothing to explain why. In the other direction, a permissive handler will
approve the digest, which `tn_delegationDigest` publishes, letting anyone
who can reach it bind an arbitrary delegator. `_getRecipient` routes every
subsequent `claimStakeRewards` and `unstake` to that delegator, so the loss
is the validator's whole stake and reward stream. Guardrails narrow who is
exposed - the caller still needs the validator's BLS key, the full stake in
`msg.value`, and `Undefined` status - but not the severity for anyone in
that set.

Route on the account kind instead of on code size. A delegation designator
is matched by its prefix, which EIP-3541 makes unambiguous, and delegated
EOAs recover through `ECDSA.tryRecoverCalldata` exactly as undelegated ones
do. Only genuine contract accounts reach ERC-1271. An operator who no
longer holds the underlying key still has `stake`, which authorizes off
`msg.sender`, and the governance path, which skips the signature entirely.

Closes #163

Alongside it, pin the ejection paths against the same class of change.
Governance screens a prospective validator before minting, but the holder
can attach account code afterwards, so screening is not durable. `burn` and
the slash-to-zero branch of `applySlashes` are already push-free: both zero
the balance ledger and consolidate the stake on Issuance before closing the
token out, so `_unstake` returns at its zero-balance guard without paying
anyone. That property was incidental rather than structural, and it was
obscured by `_consensusBurn` calling a function named `_unstake` with a
recipient it never used. Split `_burnConsensusNFT` out so the confiscating
path closes the token directly, and cover both ejection paths with hostile
validator and delegator account code, including the escrow case, which is
the one balance an ejection owes back and which already credits
`claimRefund` rather than pushing.

Closes #164
@Huwonk Huwonk self-assigned this Aug 11, 2026
…variant

Audit follow-ups on the 7702 work, no source change.

Three cases the first pass left uncovered on the delegated-EOA route: a
65-byte signature with a `v` outside {27, 28}, an empty signature, and a
differential check that the designator branch accepts exactly the signature
set the plain EOA branch accepts. The last one probes the known divergence
candidate, high-s malleability, which neither solady route screens. It is
inert here since the digest binds the delegator and a nonce and a successful
call leaves `Undefined` status behind, but the two routes must agree or the
designator branch becomes its own authorization surface. All three fail
against the pre-fix source.

`invariants.md` gains the two properties a reviewer should be able to check
the code against: which authority `delegateStake` accepts for each kind of
account, and that neither ejection path makes a recipient-facing external
call.
@Huwonk

Huwonk commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Audit pass before review

Re-reviewed the change adversarially. No defects found; three gaps closed in 196c62c (tests and docs only, no source change).

Verified

Check Result
Storage layout vs master Identical. Confirmed with forge inspect storageLayout on both. The new constant is constant, so it lives in bytecode, not a slot. Matters because the registry is genesis-injected and upgraded in place
ABI vs master Identical. No external signature changed; only bytecode needs to move downstream
Runtime bytecode 34,305 -> 34,699 bytes (+394). Already far past EIP-170 by design (genesis-injected, never CREATEd), so the delta changes nothing
Other codesize-based auth in the repo None. delegateStake is the only signature-verification site in src/; _hashTypedData is reached from exactly two places, and no other contract branches on code size
_burnConsensusNFT refactor Behaviour-identical. The removed _unstake tail was a pure read plus an early return on bal == 0, and the recipient argument was computed but unused. _unstake has no overrides
.code.length is EXTCODESIZE-only Confirmed empirically. 193 gas against an empty EOA, a tiny contract, and the 34.7KB registry alike. No code-copy cost, no griefing surface from a large-code validator
Designator prefix is unambiguous EIP-3541 forbids deployed code from starting with 0xEF, so only a 7702 designator can wear it. A 23-byte contract that is not one still routes to ERC-1271 (tested)
Delegation revocation Delegating to address(0) clears the account's code entirely, so a revoked account is codesize 0 and takes the plain EOA path
Existing delegations Unaffected. The check runs only at delegateStake time and only for Undefined validators
Reentrancy The change strictly reduces external-call surface: the 7702 route now makes no call at all where it previously made a full-gas ERC-1271 staticcall

Gaps closed

Three cases were uncovered on the delegated-EOA route, now tested and each failing against the pre-fix source:

  • 65-byte signature with v outside {27, 28} -> rejected
  • empty signature (recovers address(0)) -> rejected
  • route parity: the designator branch accepts exactly the signature set the plain EOA branch accepts

The parity test probes the one real divergence candidate. Neither solady route screens high-s, so (r, n-s, v^1) is accepted by both - verified directly, true/true. That is pre-existing behaviour, unchanged by this PR, and inert here: the digest binds the delegator and a nonce, and a successful call leaves Undefined status behind, so a malleated signature authorizes nothing new and cannot be replayed or front-run. Worth stating explicitly rather than leaving implied, since "held to their key exactly as undelegated EOAs are" is only true if the two routes agree. On master this test fails true != false.

invariants.md now states the two properties to check the code against: which authority delegateStake accepts per account kind, and that neither ejection path makes a recipient-facing external call.

Behaviour change worth a reviewer's eye

An operator who moved to a 7702 smart account and no longer controls the underlying key can no longer be onboarded through delegateStake. That is the intended trade - the alternative accepts a revocable wallet program as a stand-in for the validator identity - and two routes remain: stake, which authorizes off msg.sender, and the governance path, which skips the signature check. Documented in design.md.

Coordination

Bytecode changed, so the node's genesis allocation needs the regenerated artifacts/ConsensusRegistry.json picked up on the next submodule bump. No ABI change, so nothing else downstream moves.

Test status

  • 14 tests in ConsensusRegistryEIP7702Test.t.sol; 8 of them fail against master, 6 (the ejection ones) pass on both by design
  • Full suite: 327 passed, 0 failed, 2 skipped under the ci profile
  • Heavy fuzz pass over all consensus suites at 2000 runs: 227 passed, 0 failed

@Huwonk
Huwonk requested review from chasebrownn and grantkee August 11, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7702 dissolves EOA screening as a durable mitigation delegateStake silently switches from ecrecover to ERC-1271

3 participants